Анализ поведения пользователей в мобильном приложении "Ненужные вещи"¶

Контекст:

Приложение "Ненужные вещи" представляет собой сервис в формате доски-объявлений для продажи товаров. Различными путями, пользователь ищет и выбирает необходимый ему товар, после чего связывается с продавцом и обсуждает детали сделки. Целевое событие в рамках исследования - просмотр контактов продавца.

Цели исследования:

  • Исследовать данные о поведении пользователей, с целью последующего поиска механизмов повышения вовлеченности клиентов приложения.

  • Получить на основе поведения пользователей гипотезы о том как можно было бы улучшить приложение с точки зрения пользовательского опыта.

Описание данных:

В датасете содержатся данные пользователей, впервые совершивших действия в приложении после 7 октября 2019 года.

Колонки в /datasets/mobile_sources.csv:

  • userId — идентификатор пользователя,
  • source — источник, с которого пользователь установил приложение.

Колонки в /datasets/mobile_dataset.csv:

  • event.time — время совершения,
  • user.id — идентификатор пользователя,
  • event.name — действие пользователя.

Виды действий:

  • advert_open — открыл карточки объявления,
  • photos_show — просмотрел фотографий в объявлении,
  • tips_show — увидел рекомендованные объявления,
  • tips_click — кликнул по рекомендованному объявлению,
  • contacts_show и show_contacts — посмотрел номер телефона, contacts_call — позвонил по номеру из объявления,
  • map — открыл карту объявлений,
  • search_1 — search_7 — разные действия, связанные с поиском по сайту,
  • favorites_add — добавил объявление в избранное.

Основные этапы исследования:

1. Загрузка данных и ознакомление с ними;

2. Предобработка данных:

  • Работа с типами данных;
  • Приведение названий столбцов к разумному виду;
  • Проверка данных на пропуски и дубликаты;
  • Проверка аномалий и выбросов;
  • Объединение датасетов;
  • Создание дополнительных столбцов для целей исследования.

3. Исследовательский анализ данных:

3.1 Разделим пользователей на совершивших целевое действие (целевых) и нет (нецелевых);

3.2 Рассмотрим, из каких источников приходят пользователи?

3.3 Пришедшие из каких источников пользователи чаще смотрят контакты продавца?

3.4 Проанализируем, какие события пользователи совершают чаще? Как различаются события для целевых/нецелевых пользователей;

3.5 Изучим конверсию для пользователей в разрезе источников;

3.6. Изучим конверсию для рекомендованных объявлений. Как она различается для пользователей из разных источников?

3.7 Рассмотрим сессии пользователей. Сколько в среднем сессий приходится на пользователя? Какова средняя длинна сессии? Сколько действий совершает пользователь за сессию?

3.8 Проанализируем метрику DAU для всех пользователей приложения.

4. Основные вопросы исследования:

4.1. Проанализируем влияние событий на совершение целевого события:

  • В разрезе сессий, определим сценарии поведения пользователей, рассмотрим, как наиболее эффективно пользователи доходят до просмотра контаков.
  • Построим воронки по основным сценариям в разрезе уникальных пользователей.

4.2. Рассчитаем относительную частоту событий в разрезе двух групп пользователей - кто смотрел контакты и кто не смотрел. Сделаем выводы.

5. Проверим статистические гипотезы:

5.1 Одни пользователи совершают действия tips_show и tips_click , другие — только tips_show . Проверьте гипотезу: конверсия в просмотры контактов различается у этих двух групп.

5.2 Конверсия в просмотры контактов у пользователей кто совершил только tips_show и кто совершил tips_show и favorites_add различается.

5.3 Конверсия в просмотры контактов у пользователей кто добавил объявление в избранное выше, чем у тех кто не добавил.

6. Сформируем выводы и рекомендации для заказчика.

Загрузка данных и ознакомление с ними¶

In [1]:
#подключим нужные модули и библиотеки
import pandas as pd
import datetime as dt
import numpy as np
import matplotlib.pyplot as plt
from scipy import stats as st
import math as mth

import plotly.express as px
from plotly import graph_objects as go
from plotly.subplots import make_subplots

import requests
from tqdm import tqdm
In [2]:
# сохраним данные в переменные
mob_sources = pd.read_csv('/datasets/mobile_sources.csv')

mob_dataset = pd.read_csv('/datasets/mobile_dataset.csv')
In [3]:
def describe (row):
    for n in row:
        n.info(),display(n.head(5), ('*'*50))
    return

Вывод:

  • В обоих датасетах нет пропусков. Датасет с источиками трафика (mob_sources) содержит 4293 строк, датасет с действиями пользователей (mob_dataset) содержит 74197 строк.
  • В обоих датасетах следует привести название столбцов к рабочему виду;
  • Следует просмотреть датасеты на дубли, а также проверить неявные дубли в столбцах event.name и source;
  • В столбце event.time датасета mob_dataset следует разобраться в форматом даты и привести столбец к необходимому типу данных;
  • Датасеты следует объединить по идентификатору пользователя.

Предобработка данных¶

Приведем название столбцов к рабочему виду¶

In [4]:
mob_sources.columns = mob_sources.columns.str.replace("userId", "user_id")
print(mob_sources.columns)

mob_dataset.columns = mob_dataset.columns.str.replace(".", "_", regex=True)
mob_dataset.columns
Index(['user_id', 'source'], dtype='object')
Out[4]:
Index(['event_time', 'event_name', 'user_id'], dtype='object')

Разберемся с датой и временем¶

In [5]:
mob_dataset['event_time'] = pd.to_datetime(mob_dataset['event_time'])
# округлим время до секунд
mob_dataset['event_time'] = mob_dataset['event_time'].dt.round('S')

mob_dataset.head(5)
Out[5]:
event_time event_name user_id
0 2019-10-07 00:00:00 advert_open 020292ab-89bc-4156-9acf-68bc2783f894
1 2019-10-07 00:00:01 tips_show 020292ab-89bc-4156-9acf-68bc2783f894
2 2019-10-07 00:00:02 tips_show cf7eda61-9349-469f-ac27-e5b6f5ec475c
3 2019-10-07 00:00:07 tips_show 020292ab-89bc-4156-9acf-68bc2783f894
4 2019-10-07 00:00:56 advert_open cf7eda61-9349-469f-ac27-e5b6f5ec475c

Посмотрим, за какой период у нас данные

In [6]:
display(f"Данные с {mob_dataset['event_time'].min()} по {mob_dataset['event_time'].max()}")
'Данные с 2019-10-07 00:00:00 по 2019-11-03 23:58:13'

Объединим датасеты¶

In [7]:
# объединим по user_id
df = mob_dataset.merge(mob_sources, on=['user_id'], how='left')

df.head(5)
Out[7]:
event_time event_name user_id source
0 2019-10-07 00:00:00 advert_open 020292ab-89bc-4156-9acf-68bc2783f894 other
1 2019-10-07 00:00:01 tips_show 020292ab-89bc-4156-9acf-68bc2783f894 other
2 2019-10-07 00:00:02 tips_show cf7eda61-9349-469f-ac27-e5b6f5ec475c yandex
3 2019-10-07 00:00:07 tips_show 020292ab-89bc-4156-9acf-68bc2783f894 other
4 2019-10-07 00:00:56 advert_open cf7eda61-9349-469f-ac27-e5b6f5ec475c yandex

Поищем дубликаты¶

In [8]:
df.duplicated().sum()
Out[8]:
1118
In [9]:
duplicate_rows = df[df.duplicated()]
duplicate_rows
Out[9]:
event_time event_name user_id source
396 2019-10-07 11:00:20 tips_show fb667205-a708-4693-832d-363a30022cfc yandex
422 2019-10-07 11:10:40 map ed13f6f0-08f4-4561-852e-456580f7a40d yandex
423 2019-10-07 11:10:40 map ed13f6f0-08f4-4561-852e-456580f7a40d yandex
425 2019-10-07 11:10:41 map ed13f6f0-08f4-4561-852e-456580f7a40d yandex
430 2019-10-07 11:11:32 map ed13f6f0-08f4-4561-852e-456580f7a40d yandex
... ... ... ... ...
73011 2019-11-03 18:02:20 photos_show 6e623c43-e219-4c27-bc29-5180d9250f8d other
73427 2019-11-03 20:09:47 tips_show a0944ccf-5813-427a-966e-b146d4e9adbc google
73678 2019-11-03 21:10:40 photos_show 06edf71c-b725-47dc-acfe-0c78f079fe8f yandex
73839 2019-11-03 21:45:22 photos_show 1af9ffcd-2c77-4de0-9d35-3ff30604c9bd google
74028 2019-11-03 22:41:01 tips_show 16a5371c-152f-48d8-86fe-5636a931316b yandex

1118 rows × 4 columns

In [10]:
print('Процент дубликатов составляет:', df.duplicated().sum()/df.shape[0]*100)
Процент дубликатов составляет: 1.506799466285699
In [11]:
# удалим дубликаты
df = df.drop_duplicates().reset_index(drop=True)

Посмотрим неявные дубли

In [12]:
display(df['source'].unique())

display(df['event_name'].unique())
array(['other', 'yandex', 'google'], dtype=object)
array(['advert_open', 'tips_show', 'map', 'contacts_show', 'search_4',
       'search_5', 'tips_click', 'photos_show', 'search_1', 'search_2',
       'search_3', 'favorites_add', 'contacts_call', 'search_6',
       'search_7', 'show_contacts'], dtype=object)

В названии действий есть действия 'show_contacts' и 'contacts_show', обозначающие одно и то же. Посмотрим, как часто они встречаются и объединим их.

In [13]:
df['event_name'].sort_values().value_counts()
Out[13]:
tips_show        39892
photos_show       9372
advert_open       6145
contacts_show     4302
map               3760
search_1          3489
favorites_add     1414
search_5          1049
tips_click         811
search_4           701
contacts_call      538
search_3           521
search_6           460
search_2           324
search_7           222
show_contacts       79
Name: event_name, dtype: int64
In [14]:
df['event_name'] = df['event_name'].replace('show_contacts', 'contacts_show')
df['event_name'].sort_values().value_counts()
Out[14]:
tips_show        39892
photos_show       9372
advert_open       6145
contacts_show     4381
map               3760
search_1          3489
favorites_add     1414
search_5          1049
tips_click         811
search_4           701
contacts_call      538
search_3           521
search_6           460
search_2           324
search_7           222
Name: event_name, dtype: int64
In [15]:
df.info()
<class 'pandas.core.frame.DataFrame'>
RangeIndex: 73079 entries, 0 to 73078
Data columns (total 4 columns):
 #   Column      Non-Null Count  Dtype         
---  ------      --------------  -----         
 0   event_time  73079 non-null  datetime64[ns]
 1   event_name  73079 non-null  object        
 2   user_id     73079 non-null  object        
 3   source      73079 non-null  object        
dtypes: datetime64[ns](1), object(3)
memory usage: 2.2+ MB

Вывод по разделу¶

На этапе предобработки было сделано следующее:

  • Датасеты объединены;
  • Название столбцов приведены к рабочему виду;
  • Столбец с датой приведен к нужному типу данных (datetime64[ns]), время округлено до секунд.
  • Удалены дубликаты (1,5% от данных);
  • Действие 'просмотр контактов', изначально записанное двумя разными названиями, объединено под одним - 'contacts_show';
  • Проверен период данных. Данные представлены с 2019-10-07 00:00:00 по 2019-11-03 23:58:13;
  • Размер датасета после предобработки - 73079 строк, 4 столбца.

Исследовательский анализ данных¶

Разделим пользователей на совершивших целевое действие (целевых) и нет (нецелевых)¶

Посмотрим, сколько всего пользователей в датасете

In [16]:
display(f"Всего уникальных пользователей: {df['user_id'].nunique()}")
'Всего уникальных пользователей: 4293'
In [17]:
contacts_yes = df.query('event_name == "contacts_show"')

contacts_yes = contacts_yes['user_id'].to_list()

df['is_target'] = np.where(df['user_id'].isin(contacts_yes), 'Yes', 'No')

users = df.pivot_table(
    index = 'is_target',
    values = 'user_id',
    aggfunc = 'nunique')

users.rename(index={'No':'Нецелевые пользователи', 'Yes':'Целевые пользователи'}, inplace= True)
In [18]:
# построим круговую диаграму
fig = go.Figure(
    data=[go.Pie(labels=users.index, values=users['user_id'])])

fig.update_layout(title='Доля пользователей в датасете, совершивших и не совершивших целевое действие') 

fig.show() 

Вывод:

23% пользователей в датасете совершили целевое действие - просмотр контактов, 77% - нет.

Рассмотрим, из каких источников приходят пользователи?¶

In [19]:
display(f"Источники: {df['source'].unique()}")
"Источники: ['other' 'yandex' 'google']"
In [20]:
sources_all = df.groupby('source')['user_id'].nunique().reset_index(name='count')
In [21]:
fig = go.Figure(
    data=[go.Pie(labels=sources_all.source, values=sources_all['count'])])

fig.update_layout(title='Источники трафика по количеству пришедших пользователей') 

fig.show() 

Пришедшие из каких источников пользователи чаще смотрят контакты продавца?¶

In [22]:
sources_yes = df.query('is_target == "Yes"').groupby('source')['user_id'].nunique().reset_index(name='count')

sources_no = df.query('is_target != "Yes"').groupby('source')['user_id'].nunique().reset_index(name='count')
In [23]:
fig = make_subplots(1, 2, specs=[[{'type':'domain'}, {'type':'domain'}]],
                    subplot_titles=['Целевые пользователи', 'Нецелевые пользователи'])

fig.add_trace(go.Pie(labels=sources_yes.source, values=sources_yes['count'],
                     name="Целевые пользователи"), 1, 1)
fig.add_trace(go.Pie(labels=sources_no.source, values=sources_no['count'],
                     name="Нецелевые пользователи"), 1, 2)

fig.update_layout(title_text='Источники трафика в разрезе целевых и нецелевых пользователей')
fig.show()

Вывод:

Больше всего пользователей приходят из Яндекса (45%), причем пользователи из Яндекса чаще совершают целевое действие

Проанализируем, какие действия пользователи совершают чаще?¶

In [24]:
events = df.pivot_table(
    index = 'event_name',
    values = 'user_id',
    aggfunc = 'count').sort_values(by='user_id', ascending=False).reset_index()

events = events.rename(columns={'user_id':'count'})
In [25]:
fig = px.bar(events, x='event_name', y='count', color='event_name', text ='count')

fig.update_layout(title='Действия пользователей',
                  xaxis_title='Вид действия',
                  yaxis_title='Количество действий')
                  
fig.show()

Вывод:

Наиболее часто пользователи видят рекомендованные объявления. Помимо этого, популярные действия - просмотр фотографий объявления, переход на карточку объявления и просмотр контактов.

Посмотрим, какие действия популярны у целевой группы пользователей?

In [26]:
events_yes = df.query('is_target == "Yes"').pivot_table(
    index = 'event_name',
    values = 'user_id',
    aggfunc = 'count').sort_values(by='user_id', ascending=False).reset_index()

events_yes = events_yes.rename(columns={'user_id':'count'})
In [27]:
fig = px.bar(events_yes, x='event_name', y='count', color='event_name', text ='count')

fig.update_layout(title='Действия целевых пользователей',
                  xaxis_title='Вид действия',
                  yaxis_title='Количество действий')
                  
fig.show()

Вывод:

Для целевой группы пользователей картина немного другая. Просмотр рекомендованного объявления по-прежнему наиболее популярное действие, однако далее следует просмотр контактов и просмотр фотографий.

Изучим конверсию для пользователей в разрезе источников¶

Посмотрим на соотношение числа просмотров контактов к количеству пользователей в разрезе источников трафика.

In [28]:
source_target = df.query('event_name == "contacts_show"').pivot_table(
    index='source',
    values='event_name',
    aggfunc='count').reset_index()
In [29]:
sources_target_ratio = sources_all.merge(source_target, on='source')
In [30]:
sources_target_ratio = sources_target_ratio.rename(columns={'count':'users','event_name':'contacts_show'})
sources_target_ratio['ratio'] = round((sources_target_ratio['contacts_show']/sources_target_ratio['users'])*100,1)
In [31]:
fig = px.bar(sources_target_ratio, x='source', y='ratio', color='source', text = 'ratio')

fig.update_layout(title='Отношение числа просмотра контактов к количеству пользователей',
                  xaxis_title='Источник',
                  yaxis_title='Просмотры к кол-ву пользователей')
                  
fig.show()

Вывод:

Из Яндекса приходит больше всего пользователей, однако соотношение числа просмотров контактов к количеству пользователей ниже, чем у Google.

Оценим конверсию пользователей в разрезе источников трафика. Выделим уникальных пользователей по каждому источнику трафика и посмотрим сколько из них совершили целевое действие. Используем для расчетов созданные ранее переменные.

In [32]:
cr_source = sources_all.merge(sources_yes, on='source')
cr_source = cr_source.rename(columns={'count_x':'count', 'count_y':'target'})
cr_source['cr_ratio'] = round((cr_source['target']/cr_source['count']) * 100, 2)

cr_source
Out[32]:
source count target cr_ratio
0 google 1129 275 24.36
1 other 1230 228 18.54
2 yandex 1934 478 24.72
In [33]:
fig = px.bar(cr_source, x='source', y='cr_ratio', color='source', text = 'cr_ratio')

fig.update_layout(title='Конверсия пользователей в целевое действие по истоникам',
                  xaxis_title='Источник',
                  yaxis_title='Конверсия, %')
                  
fig.show()

Вывод:

При том, что больше пользователей приходит из Яндекса, показатели конверсии в целевое действие у пользователей из Яндекса и Google находятся примерно на одном уровне.

Изучим конверсию для рекомендованных объявлений. Как она различается для пользователей из разных источников?¶

Посмотрим на соотношение числа просмотров контактов к количеству целевых пользователей в разрезе источников трафика.

In [34]:
tips_show = df.query('event_name == "tips_show"')

tips_show_users = tips_show['user_id'].to_list()

tips_show_target = df.query('user_id in @tips_show_users and event_name == "contacts_show"')
In [35]:
tips_show = tips_show.pivot_table(
    index='source',
    values='user_id',
    aggfunc='nunique').reset_index()

tips_show_target = tips_show_target.pivot_table(
    index='source',
    values='event_name',
    aggfunc='count').reset_index()
In [36]:
tips_show_ratio = tips_show.merge(tips_show_target, on='source')
tips_show_ratio = tips_show_ratio.rename(columns={'user_id':'users','event_name':'contacts_show'})
tips_show_ratio['ratio'] = round((tips_show_ratio['contacts_show']/tips_show_ratio['users'])*100,1)
In [37]:
fig = px.bar(tips_show_ratio, x='source', y='ratio', color='source', text = 'ratio')

fig.update_layout(title='Число просмотра контактов к кол-ву пользователей, смотревших рекомендованные объявлений',
                  xaxis_title='Источник',
                  yaxis_title='Конверсия, %')
                  
fig.show()

Вывод:

В целом, ситуация схожа с графиком соотношения числа просмотра контактов к общему количеству пользователей. Однако в разрезе рекомендованных объявлений заметнее увеличение числа просмотра контактов для пользователей из Google и снижение для пользователей из Яндекса.

Оценим конверсию пользователей, смотревших рекомендованные объявления.

In [38]:
tips_show_count = df.query('event_name == "tips_show"').pivot_table(
    index = 'source',
    values = 'user_id',
    aggfunc = 'nunique').reset_index()

users_tips_show = df.query('event_name == "tips_show"')['user_id'].to_list()
tips_show_and_target = df.query('user_id in @users_tips_show and event_name == "contacts_show"')

tips_show_and_target = tips_show_and_target.pivot_table(
    index = 'source',
    values = 'user_id',
    aggfunc = 'nunique').reset_index()

cr_tips_show_source = tips_show_count.merge(tips_show_and_target, on='source' )
cr_tips_show_source = cr_tips_show_source.rename(columns={'user_id_x':'count', 'user_id_y':'target'})
cr_tips_show_source['cr_ratio'] = round((cr_tips_show_source['target']/cr_tips_show_source['count']) * 100, 2)

cr_tips_show_source
Out[38]:
source count target cr_ratio
0 google 651 121 18.59
1 other 840 127 15.12
2 yandex 1310 268 20.46
In [39]:
fig = px.bar(cr_tips_show_source, x='source', y='cr_ratio', color='source', text = 'cr_ratio')

fig.update_layout(title='Конверсия пользователей из просмотра рек. объявлений в целевое действие по истоникам',
                  xaxis_title='Источник',
                  yaxis_title='Конверсия, %')
                  
fig.show()

Вывод:

Наибольшую конверсию из рекомендованных объявлений в целевое действие демонстрируют пользователи, пришедшие из Яндекса (20,46%).

Рассмотрим сессии пользователей. Сколько в среднем сессий приходится на пользователя? Какова средняя длинна сессии? Сколько действий совершает пользователь за сессию?¶

Для начала выделим пользовательские сессии.

Будем выделять сессии через тайм-аут сессии, то есть через интервал времени бездействия пользователя, после которого считается, что пользовательская сессия закончилась. Установим, что время тайм-аута сессии составляет 30 минут. В Яндекс.Метрике и Google Analytics по умолчанию тайм-аут сессии как раз составляет 30 минут.

Яндекс.Метрика, дефолтное значение тайм-аута сессии составляет 30 минут.

https://yandex.ru/support/metrica/general/counter-general.html#:~:text=%D0%A2%D0%B0%D0%B9%D0%BC%2D%D0%B0%D1%83%D1%82%20%D0%B2%D0%B8%D0%B7%D0%B8%D1%82%D0%B0,%D1%81%D0%BE%D1%81%D1%82%D0%B0%D0%B2%D0%BB%D1%8F%D0%B5%D1%82%C2%A030%C2%A0%D0%BC%D0%B8%D0%BD%D1%83%D1%82.

Google Analytics 4, дефолтное значение аналогично составляет 30 мин. https://support.google.com/analytics/answer/9191807#:~:text=%D0%A7%D1%82%D0%BE%20%D1%81%D1%87%D0%B8%D1%82%D0%B0%D0%B5%D1%82%D1%81%D1%8F%20%D1%81%D0%B5%D0%B0%D0%BD%D1%81%D0%BE%D0%BC,%D1%8D%D1%82%D0%BE%D0%BC%20%D0%BD%D0%B5%20%D0%BE%D0%B3%D1%80%D0%B0%D0%BD%D0%B8%D1%87%D0%B5%D0%BD%D0%B0.

In [40]:
# отсортируем датафрейм
df = df.sort_values(['user_id', 'event_time'])
In [41]:
# определим разницу в 30 мин
g = (df.groupby('user_id')['event_time'].diff() > pd.Timedelta('30Min')).cumsum()
# создадим группы
df['session_id'] = df.groupby(['user_id', g], sort=False).ngroup() + 1

Сколько в среднем сессий приходится на пользователя?

In [42]:
count_session = df.pivot_table(
    index = 'user_id',
    values = 'session_id',
    aggfunc = 'nunique')

count_session = count_session.rename(columns={'session_id':'count'})
In [43]:
fig = px.histogram(count_session, x='count')

fig.update_layout(title='Распределение сессий',
                  xaxis_title='Кол-во сессий',
                  yaxis_title='Кол-во наблюдений')
fig.show()
In [44]:
count_session.describe()
Out[44]:
count
count 4293.000000
mean 2.415094
std 3.536466
min 1.000000
25% 1.000000
50% 1.000000
75% 3.000000
max 99.000000
In [45]:
np.percentile(count_session, 95)
Out[45]:
7.0

Вывод:

Выявлены пользователи с аномальным количеством сессий. Однако в среднем, количесво сессий на пользователя не превышает 3.

Посмотрим на среднюю длинну сессии

In [46]:
session_duration = df.pivot_table(
    index = 'session_id',
    values = 'event_time',
    aggfunc = ['min', 'max'])

session_duration['duration'] = session_duration['max'] - session_duration['min']
In [47]:
session_duration['duration'].describe()
Out[47]:
count                        10368
mean     0 days 00:12:52.668981481
std      0 days 00:19:51.607952024
min                0 days 00:00:00
25%                0 days 00:00:21
50%                0 days 00:05:35
75%         0 days 00:17:25.250000
max                0 days 05:21:58
Name: duration, dtype: object

Вывод:

Средняя длина сессии находится в районе 6 минут.

In [48]:
session_duration['seconds'] = session_duration['duration'].dt.total_seconds().round()
print(len(session_duration[session_duration['seconds'] == 0]))

print(len(session_duration))
2142
10368

Посмотрим, сколько действий совершает пользователь за сессию?

In [49]:
session_events = df.pivot_table(
    index = 'session_id',
    values = 'event_name',
    aggfunc = 'count')

session_events = session_events.rename(columns={'event_name':'event_count'})

fig = px.histogram(session_events, x='event_count', nbins=200)

fig.update_layout(title='Распределение действий',
                  xaxis_title='Кол-во действий',
                  yaxis_title='Кол-во наблюдений')

fig.show()
In [50]:
session_events.describe()
Out[50]:
event_count
count 10368.000000
mean 7.048515
std 9.415075
min 1.000000
25% 2.000000
50% 4.000000
75% 8.000000
max 149.000000
In [51]:
np.percentile(session_events, 95)
Out[51]:
24.0

Вывод:

Зачастую, пользователи совершают не более 24 действий за сессию. Среднее значение лежит около 4 действий за сессию.

Проанализируем метрику DAU для всех пользователей приложения.¶

In [52]:
df_dau = df.copy()
df_dau['event_date'] = df['event_time'].dt.date

dau = df_dau.groupby('event_date')['user_id'].nunique()
dau.describe()
Out[52]:
count     28.000000
mean     279.178571
std       46.737291
min      178.000000
25%      238.250000
50%      292.500000
75%      310.500000
max      352.000000
Name: user_id, dtype: float64
In [53]:
fig = go.Figure()
fig.add_trace(go.Box(x=dau))

fig.update_layout(title='DAU (daily active users)',
                  xaxis_title='Количество пользователей')

fig.show()

Вывод:

В среднем количество активных пользователей в день находится в районе 240 - 300 человек.

Вывод по разделу¶

  • Всего уникальных пользователей в датасете: 4293;
  • 23% пользователей в датасете совершили целевое действие (981 человек) - просмотр контактов, 77% - нет (3312 человек);
  • В датасете содержатся 3 истоника трафика - Яндекс, Google, Другие. Больше всего пользователей приходят из Яндекса (45%), причем пользователи из Яндекса чаще совершают целевое действие.
  • Самое распространенное действие - просмотр рекомендованных объявлений. Помимо этого, популярные действия - просмотр фотографий объявления, переход на карточку объявления и просмотр контактов;
  • Для целевой группы пользователей просмотр рекомендованного объявления так же наиболее популярное действие, однако далее следует просмотр контактов и просмотр фотографий;
  • При том, что больше пользователей приходит из Яндекса, показатели конверсии в целевое действие у пользователей из Яндекса и Google находятся примерно на одном уровне.
  • Наибольшую конверсию из рекомендованных объявлений в целевое действие демонстрируют пользователи, пришедшие из Яндекса (20,46%).
  • В среднем, на пользователя приходится 3 сессии, а средняя длина сессии находится в районе 6 минут. При этом, пользователи совершают не более 24 действий за сессию. Среднее значение лежит около 4 действий за сессию.
  • Метрика DAU показывает, что в среднем количество активных пользователей в день находится в районе 240 - 300 человек.

Основные вопросы исследования¶

Проанализируем влияние событий на совершение целевого события:¶

В разрезе сессий, определим сценарии поведения пользователей, рассмотрим, как наиболее эффективно пользователи доходят до просмотра контаков. Для поиска сценарий будем использовать диаграмму Сэнкей.¶

Сначала, избавимся от повторяющихся событий в рамках сессии.

In [54]:
df_filtered = df.drop_duplicates(subset=['session_id','event_name'], keep='first')

Через функцию, определим пары source-target (пары событий, в которых происходит переход пользователей от одного события (source) к другому (target). Также, пронумеруем эти пары на основании времени наступления события, то есть таким образом получим шаг в рамках диаграммы.

In [55]:
def add_features(df):
    
    """Функция генерации новых столбцов для исходной таблицы

    Args:
        df (pd.DataFrame): исходная таблица.
    Returns:
        pd.DataFrame: таблица с новыми признаками.
    """
    
    # сортируем по id и времени
    sorted_df = df.sort_values(by=['user_id', 'event_time']).copy()
    # добавляем шаги событий
    sorted_df['step'] = sorted_df.groupby('session_id').cumcount() + 1
    
    # добавляем узлы-источники и целевые узлы
    # узлы-источники - это сами события
    sorted_df['source'] = sorted_df['event_name']
    # добавляем целевые узлы
    sorted_df['target'] = sorted_df.groupby('session_id')['source'].shift(-1)
    
    # возврат таблицы без имени событий
    return sorted_df.drop(['event_name'], axis=1)
In [56]:
# преобразуем таблицу
df_filtered = add_features(df_filtered)
df_filtered.head()
Out[56]:
event_time user_id source is_target session_id step target
795 2019-10-07 13:39:46 0001b1d5-b74a-4cbf-aeb0-7df5947bf349 tips_show No 1 1 NaN
6471 2019-10-09 18:33:56 0001b1d5-b74a-4cbf-aeb0-7df5947bf349 map No 2 1 tips_show
6495 2019-10-09 18:40:29 0001b1d5-b74a-4cbf-aeb0-7df5947bf349 tips_show No 2 2 NaN
35878 2019-10-21 19:52:31 0001b1d5-b74a-4cbf-aeb0-7df5947bf349 tips_show No 3 1 map
35885 2019-10-21 19:53:39 0001b1d5-b74a-4cbf-aeb0-7df5947bf349 map No 3 2 NaN

Теперь нам нужно определить, какое количество шагов для диаграммы выбрать? Для этого расмотрим как распределяются шаги в пользовательских сессиях.

In [57]:
steps = df_filtered.groupby('session_id')['step'].agg('count')

np.percentile(steps, 99)
Out[57]:
6.0

Видим, что в целом за сессию, пользователь совершает не более 6 шагов. Установим это значение в качестве ограничения для построения диаграммы.

In [58]:
df_comp = df_filtered[df_filtered['step'] <= 6].copy().reset_index(drop=True)

Далее, нам необходимо создать индексы для source. Поскольку на каждом следующем шаге target становится source, появляется возможность корректно оценить переход от одного действия к другому, а значит, необходима правильная индексация.

In [59]:
def get_source_index(df):
    
    """Функция генерации индексов source

    Args:
        df (pd.DataFrame): исходная таблица с признаками step, source, target.
    Returns:
        dict: словарь с индексами, именами и соответсвиями индексов именам source.
    """
    
    res_dict = {}
    
    count = 0
    # получаем индексы источников
    for no, step in enumerate(df['step'].unique().tolist()):
        # получаем уникальные наименования для шага
        res_dict[no+1] = {}
        res_dict[no+1]['sources'] = df[df['step'] == step]['source'].unique().tolist()
        res_dict[no+1]['sources_index'] = []
        for i in range(len(res_dict[no+1]['sources'])):
            res_dict[no+1]['sources_index'].append(count)
            count += 1
            
    # соединим списки
    for key in res_dict:
        res_dict[key]['sources_dict'] = {}
        for name, no in zip(res_dict[key]['sources'], res_dict[key]['sources_index']):
            res_dict[key]['sources_dict'][name] = no
    return res_dict
  

# создаем словарь
source_indexes = get_source_index(df_comp)

Теперь, необходимо сгенерировать цвета для диаграмы, чтобы лучше были выделены переходы от одного действия (источника) к другому.

In [60]:
def generate_random_color():
    
    """Случайная генерация цветов rgba

    Args:
        
    Returns:
        str: Строка со сгенерированными параметрами цвета
    """
    
    # сгенерим значение для каждого канала
    r, g, b = np.random.randint(255, size=3)
    return f'rgba({r}, {g}, {b}, 1)'
In [61]:
def colors_for_sources(mode):
    
    """Генерация цветов rgba

    Args:
        mode (str): сгенерировать случайные цвета, если 'random', а если 'custom' - 
                    использовать заранее подготовленные
    Returns:
        dict: словарь с цветами, соответствующими каждому индексу
    """
    # словарь, в который сложим цвета в соответствии с индексом
    colors_dict = {}
    
    if mode == 'random':
        # генерим случайные цвета
        for label in df_comp['source'].unique():
            r, g, b = np.random.randint(255, size=3)            
            colors_dict[label] = f'rgba({r}, {g}, {b}, 1)'
            
    elif mode == 'custom':
        # присваиваем ранее подготовленные цвета
        colors = requests.get('https://raw.githubusercontent.com/rusantsovsv/senkey_tutorial/main/json/colors_senkey.json').json()
        for no, label in enumerate(df_comp['source'].unique()):
            colors_dict[label] = colors['custom_colors'][no]
            
    return colors_dict
  
colors_dict = colors_for_sources(mode='custom')

Для грамотного отображения данных, создадим словарь со списками данных для диаграммы.

In [62]:
def percent_users(sources, targets, values):
    
    """
    Расчет уникальных id в процентах (для вывода в hover text каждого узла)
    
    Args:
        sources (list): список с индексами source.
        targets (list): список с индексами target.
        values (list): список с "объемами" потоков.
        
    Returns:
        list: список с "объемами" потоков в процентах
    """
    
    # объединим источники и метки и найдем пары
    zip_lists = list(zip(sources, targets, values))
    
    new_list = []
    
    # подготовим список словарь с общим объемом трафика в узлах
    unique_dict = {}
    
    # проходим по каждому узлу
    for source, target, value in zip_lists:
        if source not in unique_dict:
            # находим все источники и считаем общий трафик
            unique_dict[source] = 0
            for sr, tg, vl in zip_lists:
                if sr == source:
                    unique_dict[source] += vl
                    
    # считаем проценты
    for source, target, value in zip_lists:
        new_list.append(round(100 * value / unique_dict[source], 1))
    
    return new_list
In [63]:
def lists_for_plot(source_indexes=source_indexes, colors=colors_dict, frac=10):
    
    """
    Создаем необходимые для отрисовки диаграммы переменные списков и возвращаем
    их в виде словаря
    
    Args:
        source_indexes (dict): словарь с именами и индексами source.
        colors (dict): словарь с цветами source.
        frac (int): ограничение на минимальный "объем" между узлами.
        
    Returns:
        dict: словарь со списками, необходимыми для диаграммы.
    """
    
    sources = []
    targets = []
    values = []
    labels = []
    link_color = []
    link_text = []

    # проходим по каждому шагу
    for step in tqdm(sorted(df_comp['step'].unique()), desc='Шаг'):
        if step + 1 not in source_indexes:
            continue

        # получаем индекс источника
        temp_dict_source = source_indexes[step]['sources_dict']

        # получаем индексы цели
        temp_dict_target = source_indexes[step+1]['sources_dict']

        # проходим по каждой возможной паре, считаем количество таких пар
        for source, index_source in tqdm(temp_dict_source.items()):
            for target, index_target in temp_dict_target.items():
                # делаем срез данных и считаем количество id            
                temp_df = df_comp[(df_comp['step'] == step)&(df_comp['source'] == source)&(df_comp['target'] == target)]
                value = len(temp_df)
                # проверяем минимальный объем потока и добавляем нужные данные
                if value > frac:
                    sources.append(index_source)
                    targets.append(index_target)
                    values.append(value)
                    # делаем поток прозрачным для лучшего отображения
                    link_color.append(colors[source].replace(', 1)', ', 0.2)'))
                    
    labels = []
    colors_labels = []
    for key in source_indexes:
        for name in source_indexes[key]['sources']:
            labels.append(name)
            colors_labels.append(colors[name])
            
    # посчитаем проценты всех потоков
    perc_values = percent_users(sources, targets, values)
    
    # добавим значения процентов для howertext
    link_text = []
    for perc in perc_values:
        link_text.append(f"{perc}%")
    
    # возвратим словарь с вложенными списками
    return {'sources': sources, 
            'targets': targets, 
            'values': values, 
            'labels': labels, 
            'colors_labels': colors_labels, 
            'link_color': link_color, 
            'link_text': link_text}
  

# создаем словарь
data_for_plot = lists_for_plot()
Шаг:   0%|          | 0/6 [00:00<?, ?it/s]
  0%|          | 0/14 [00:00<?, ?it/s]
 21%|██▏       | 3/14 [00:00<00:00, 26.43it/s]
 43%|████▎     | 6/14 [00:00<00:00, 26.85it/s]
 64%|██████▍   | 9/14 [00:00<00:00, 26.09it/s]
100%|██████████| 14/14 [00:00<00:00, 25.99it/s]
Шаг:  17%|█▋        | 1/6 [00:00<00:02,  1.85it/s]
  0%|          | 0/15 [00:00<?, ?it/s]
 20%|██        | 3/15 [00:00<00:00, 26.25it/s]
 40%|████      | 6/15 [00:00<00:00, 25.24it/s]
 60%|██████    | 9/15 [00:00<00:00, 25.52it/s]
 80%|████████  | 12/15 [00:00<00:00, 23.98it/s]
100%|██████████| 15/15 [00:00<00:00, 24.56it/s]
Шаг:  33%|███▎      | 2/6 [00:01<00:02,  1.71it/s]
  0%|          | 0/15 [00:00<?, ?it/s]
 20%|██        | 3/15 [00:00<00:00, 25.27it/s]
 40%|████      | 6/15 [00:00<00:00, 25.33it/s]
 60%|██████    | 9/15 [00:00<00:00, 26.00it/s]
 80%|████████  | 12/15 [00:00<00:00, 24.70it/s]
100%|██████████| 15/15 [00:00<00:00, 25.27it/s]
Шаг:  50%|█████     | 3/6 [00:01<00:01,  1.70it/s]
  0%|          | 0/15 [00:00<?, ?it/s]
 20%|██        | 3/15 [00:00<00:00, 26.37it/s]
 40%|████      | 6/15 [00:00<00:00, 26.75it/s]
 60%|██████    | 9/15 [00:00<00:00, 27.17it/s]
 80%|████████  | 12/15 [00:00<00:00, 26.80it/s]
100%|██████████| 15/15 [00:00<00:00, 26.88it/s]
Шаг:  67%|██████▋   | 4/6 [00:02<00:01,  1.73it/s]
  0%|          | 0/14 [00:00<?, ?it/s]
 29%|██▊       | 4/14 [00:00<00:00, 32.26it/s]
 57%|█████▋    | 8/14 [00:00<00:00, 32.70it/s]
100%|██████████| 14/14 [00:00<00:00, 31.51it/s]
Шаг: 100%|██████████| 6/6 [00:02<00:00,  2.17it/s]

Теперь, создадим объект диаграммы.

In [64]:
def plot_senkey_diagram(data_dict=data_for_plot):    
    
    """
    Функция для генерации объекта диаграммы Сенкей 
    
    Args:
        data_dict (dict): словарь со списками данных для построения.
        
    Returns:
        plotly.graph_objs._figure.Figure: объект изображения.
    """
    
    fig = go.Figure(data=[go.Sankey(
        domain = dict(
          x =  [0,1],
          y =  [0,1]
        ),
        orientation = "h",
        valueformat = ".0f",
        node = dict(
          pad = 50,
          thickness = 15,
          line = dict(color = "black", width = 0.1),
          label = data_dict['labels'],
          color = data_dict['colors_labels']
        ),
        link = dict(
          source = data_dict['sources'],
          target = data_dict['targets'],
          value = data_dict['values'],
          label = data_dict['link_text'],
          color = data_dict['link_color']
      ))])
    fig.update_layout(title_text="Диаграмма Сэнкей для действий пользователей", font_size=10, width=1000, height=700)
    
    # возвращаем объект диаграммы
    return fig
  

# сохраняем диаграмму в переменную
senkey_diagram = plot_senkey_diagram()
In [65]:
senkey_diagram.show()

Вывод

Исходя из толщины потоков, отображаемых на диаграмме, можем выделить основные сценарии действий пользователей, приводящих к целевому действию (просмотр контактов):

Сценарий 1. search_1 -- photos_show -- contacts_show

Сценарий 2. tips_show -- contacts_show

Сценарий 3. map -- tips_show -- contacts_show

Cценарий 4. map -- advert_open -- contacts_show

Построим воронки по основным сценариям в разрезе уникальных пользователей.¶

Воронка для сценария search_1 -- photos_show -- contacts_show

In [66]:
col_names = ['event','user_count']
scenario_1 = pd.DataFrame(columns = col_names) 
scenario_1['event'] = ['search_1','photos_show', 'contacts_show']

row_1 = []
step_1 = df.query('event_name == "search_1"')
row_1.append(step_1['user_id'].nunique())
step_1_users = step_1['user_id'].to_list()

step_2 = df.query('user_id in @step_1_users and event_name == "photos_show"')
row_1.append(step_2['user_id'].nunique())
step_2_users = step_2['user_id'].to_list()

step_3 = df.query('user_id in @step_2_users and event_name == "contacts_show"')
row_1.append(step_3['user_id'].nunique())

scenario_1['user_count'] = pd.Series(row_1)

scenario_1
Out[66]:
event user_count
0 search_1 787
1 photos_show 643
2 contacts_show 191
In [67]:
from plotly import graph_objects as go

fig = go.Figure(go.Funnel(
    y = scenario_1['event'],
    x = scenario_1['user_count'],
    textposition = "inside",
    textinfo = "value+percent initial+percent previous",
    marker = {"color": ["#104a5c", "#0f8e54", "#ffa600"],
    "line": {"width": [3, 2, 2], "color": ["white", "white", "white"]}},
    connector = {"fillcolor": '#a6bddb'},
        insidetextfont = {'color': 'white', 'size': 14})
    )

fig.update_layout(title='Воронка для сценария search_1 -- photos_show -- contacts_show')
fig.show()

Вывод:

В сценарии search_1 -- photos_show -- contacts_show, на первом шаге находятся 787 пользователей, а 24% пользователей доходят до целевого действия.

Воронка для сценария tips_show -- contacts_show

In [68]:
col_names = ['event','user_count']
scenario_2 = pd.DataFrame(columns = col_names) 
scenario_2['event'] = ['tips_show', 'contacts_show']

row_2 = []
step_1 = df.query('event_name == "tips_show"')
row_2.append(step_1['user_id'].nunique())
step_1_users = step_1['user_id'].to_list()

step_2 = df.query('user_id in @step_1_users and event_name == "contacts_show"')
row_2.append(step_2['user_id'].nunique())
step_2_users = step_2['user_id'].to_list()

scenario_2['user_count'] = pd.Series(row_2)

scenario_2
Out[68]:
event user_count
0 tips_show 2801
1 contacts_show 516
In [69]:
fig = go.Figure(go.Funnel(
    y = scenario_2['event'],
    x = scenario_2['user_count'],
    textposition = "inside",
    textinfo = "value+percent initial+percent previous",
    marker = {"color": ["#104a5c", "#ffa600"],
    "line": {"width": [3, 2], "color": ["white", "white"]}},
    connector = {"fillcolor": '#a6bddb'},
        insidetextfont = {'color': 'white', 'size': 14})
    )

fig.update_layout(title='Воронка для сценария tips_show -- contacts_show')
fig.show()

Вывод:

Сценарий tips_show -- contacts_show, является наиболее коротким. На первом шаге находятся 2801 пользователь, однако только 18% пользователей доходят до целевого действия.

Воронка для сценария map -- tips_show -- contacts_show

In [70]:
col_names = ['event','user_count']
scenario_3 = pd.DataFrame(columns = col_names) 
scenario_3['event'] = ['map', 'tips_show', 'contacts_show']

row_3 = []
step_1 = df.query('event_name == "map"')
row_3.append(step_1['user_id'].nunique())
step_1_users = step_1['user_id'].to_list()

step_2 = df.query('user_id in @step_1_users and event_name == "tips_show"')
row_3.append(step_2['user_id'].nunique())
step_2_users = step_2['user_id'].to_list()

step_3 = df.query('user_id in @step_2_users and event_name == "contacts_show"')
row_3.append(step_3['user_id'].nunique())

scenario_3['user_count'] = pd.Series(row_3)

scenario_3
Out[70]:
event user_count
0 map 1456
1 tips_show 1352
2 contacts_show 275
In [71]:
fig = go.Figure(go.Funnel(
    y = scenario_3['event'],
    x = scenario_3['user_count'],
    textposition = "inside",
    textinfo = "value+percent initial+percent previous",
    marker = {"color": ["#104a5c", "#0f8e54", "#ffa600"],
    "line": {"width": [3, 2, 2], "color": ["white", "white", "white"]}},
    connector = {"fillcolor": '#a6bddb'},
        insidetextfont = {'color': 'white', 'size': 14})
    )

fig.update_layout(title='Воронка для сценария map -- tips_show -- contacts_show')
fig.show()

Вывод:

В сценарии map -- tips_show -- contacts_show, на первом шаге находятся 1456 пользователей, при этом 19% пользователей доходят до целевого действия.

Воронка для сценария map -- advert_open -- contacts_show

In [72]:
col_names = ['event','user_count']
scenario_4 = pd.DataFrame(columns = col_names) 
scenario_4['event'] = ['map', 'advert_open', 'contacts_show']

row_4 = []
step_1 = df.query('event_name == "map"')
row_4.append(step_1['user_id'].nunique())
step_1_users = step_1['user_id'].to_list()

step_2 = df.query('user_id in @step_1_users and event_name == "advert_open"')
row_4.append(step_2['user_id'].nunique())
step_2_users = step_2['user_id'].to_list()

step_3 = df.query('user_id in @step_2_users and event_name == "contacts_show"')
row_4.append(step_3['user_id'].nunique())

scenario_4['user_count'] = pd.Series(row_4)

scenario_4
Out[72]:
event user_count
0 map 1456
1 advert_open 517
2 contacts_show 86
In [73]:
fig = go.Figure(go.Funnel(
    y = scenario_4['event'],
    x = scenario_4['user_count'],
    textposition = "inside",
    textinfo = "value+percent initial+percent previous",
    marker = {"color": ["#104a5c", "#0f8e54", "#ffa600"],
    "line": {"width": [3, 2, 2], "color": ["white", "white", "white"]}},
    connector = {"fillcolor": '#a6bddb'},
        insidetextfont = {'color': 'white', 'size': 14})
    )

fig.update_layout(title='Воронка для сценария map -- advert_open -- contacts_show')
fig.show()

Вывод:

В сценарии map -- advert_open -- contacts_show, на первом шаге находятся 1456 пользователей, при этом 6% пользователей доходят до целевого действия. Это наиболее низкая конверсия среди рассмотренных сценариев.

Рассчитаем относительную частоту событий в разрезе двух групп пользователей - кто смотрел контакты и кто не смотрел. Сделаем выводы.¶

In [74]:
target_user = df.query('is_target == "Yes"')

target_user = target_user.pivot_table(
    index = 'event_name',
    values = 'event_time',
    aggfunc = 'count').reset_index().sort_values(by='event_time', ascending=False)
    
target_user = target_user.rename(columns={'event_time':'count'})
target_user['ratio'] = round((target_user['count'] / target_user['count'].sum())*100, 2)
target_user
Out[74]:
event_name count ratio
14 tips_show 12698 47.74
2 contacts_show 4381 16.47
5 photos_show 3497 13.15
0 advert_open 1585 5.96
6 search_1 1340 5.04
4 map 1066 4.01
1 contacts_call 538 2.02
3 favorites_add 421 1.58
13 tips_click 332 1.25
10 search_5 249 0.94
9 search_4 149 0.56
8 search_3 143 0.54
7 search_2 96 0.36
11 search_6 74 0.28
12 search_7 31 0.12
In [75]:
no_target_user = df.query('is_target == "No"')

no_target_user = no_target_user.pivot_table(
    index = 'event_name',
    values = 'event_time',
    aggfunc = 'count').reset_index().sort_values(by='event_time', ascending=False)
    
no_target_user = no_target_user.rename(columns={'event_time':'count'})
no_target_user['ratio'] = round((no_target_user['count'] / no_target_user['count'].sum())*100, 2)
no_target_user
Out[75]:
event_name count ratio
12 tips_show 27194 58.51
3 photos_show 5875 12.64
0 advert_open 4560 9.81
2 map 2694 5.80
4 search_1 2149 4.62
1 favorites_add 993 2.14
8 search_5 800 1.72
7 search_4 552 1.19
11 tips_click 479 1.03
9 search_6 386 0.83
6 search_3 378 0.81
5 search_2 228 0.49
10 search_7 191 0.41
In [76]:
freq_events = target_user.merge(no_target_user, on='event_name', how='left')
freq_events.columns = ['event_name', 'target_count', 'target_ratio', 'no_target_count', 'no_target_ratio']
In [77]:
fig = go.Figure(data=[
    go.Bar(name='Смотревшие контакты', 
           x=freq_events['event_name'], 
           y=freq_events['target_ratio'],
           text=freq_events['target_ratio'],
           textposition='auto'),
    
    go.Bar(name='Не смотревшие контакты', 
           x=freq_events['event_name'], 
           y=freq_events['no_target_ratio'],
           text=freq_events['no_target_ratio'],
           textposition='auto')
])

fig.update_layout(barmode='group', title='Относительная частота событий в разрезе \
двух групп пользователей, в %')
fig.show()

Вывод:

  • Для обоих групп пользователей заметно преобладание действия tips_show среди общего количества действий. Для целевых пользователей действие tips_show занимает 48%, для пользователей, не смотревших контакты доля tips_show составляет 59% от общего числа действий в группе.
  • Заметна разница в действиях advert_open. Пользователи, не смотревшие контакты чаще реагируют на рекламные объявления (10% от общего кол-ва действий, против 6% у целевых пользователей);
  • Также, заметна разница в действиях map. Целевые пользователи меньше взаимодействуют с картой (4% от общего кол-ва действий против 6% от общего кол-ва действий у не целевых пользователей);
  • В остальных дейтсвиях тенденции схожи для целевых и не целевых групп.

Вывод по разделу¶

  • Определены сценарии поведения пользователей, построена диаграмма Сэнкей. Выделены 4 основных сценария, среди которых:

  • Сценарий 1. search_1 -- photos_show -- contacts_show. СR сценария 1 = 24%

  • Сценарий 2. tips_show -- contacts_show. СR сценария 2 = 18%

  • Сценарий 3. map -- tips_show -- contacts_show. СR сценария 3 = 19%

  • Cценарий 4. map -- advert_open -- contacts_show. СR сценария 4 = 6%

  • Рассмотрена относительная частота событий в разрезе двух групп пользователей - кто смотрел контакты и кто не смотрел, сделаны выводы, среди которых:

  • Для обоих групп пользователей заметно преобладание действия tips_show среди общего количества действий.

  • Заметна разница в действиях advert_open и map. Среди нецелевых пользователей доля этих действий среди всех выше, чем у целевых пользователей.

Проверим статистические гипотезы¶

Одни пользователи совершают действия tips_show и tips_click , другие — только tips_show . Проверим гипотезу: конверсия в просмотры контактов различается у этих двух групп.¶

Сформируем нулевую и альтернативную гипотезы.

H0: различий в ĸонверсии в просмотры ĸонтаĸтов у групп нет;

H1: ĸонверсия в просмотры ĸонтаĸтов различается у групп пользователей.

Для теста нам необходимо будет проводить сравнения долей генеральных совокупностей по выборкам из них. Разница между пропорциями, наблюдаемыми на выборках, будет нашей статистикой. Будем использовать Z-критерий двух пропорций.

Подготовим данные с ĸонверсией пользователей, совершивших tips_show и tips_click в просмотры ĸонтаĸтов и тольĸо tips_show в просмотры ĸонтаĸтов.

In [78]:
all_tips_show = df.query('event_name == "tips_show"')
all_tips_show_list = all_tips_show['user_id'].to_list()
all_tips_show_count = all_tips_show['user_id'].nunique()

all_tips_show_and_tips_click = df.query('user_id in @all_tips_show_list and event_name == "tips_click"')
all_tips_show_and_tips_click_count = all_tips_show_and_tips_click['user_id'].nunique()
In [79]:
target_tips_show = df.query('user_id in @contacts_yes and event_name == "tips_show"')
target_tips_show_list = target_tips_show['user_id'].to_list()
target_tips_show_count = target_tips_show['user_id'].nunique()


target_tips_show_and_tips_click = df.query('user_id in @target_tips_show_list and event_name == "tips_click"')
target_tips_show_and_tips_click_count = target_tips_show_and_tips_click['user_id'].nunique()

Подчистим пользователей, кто совершил только tips_show. Вычтем из пользователей с tips_show, пользователей с tips_show и tips_click в общей и целевой группах.

In [80]:
all_tips_show_count = all_tips_show_count - all_tips_show_and_tips_click_count
target_tips_show_count = target_tips_show_count - target_tips_show_and_tips_click_count
In [81]:
print('Кол-во пользователей, совершивших tips_show', all_tips_show_count)
print('Кол-во пользователей, совершивших tips_show с целевым действием', target_tips_show_count)
print()
print('Кол-во пользователей, совершивших tips_show и tips_click', all_tips_show_and_tips_click_count)
print('Кол-во пользователей, совершивших tips_show и tips_click с целевым действием', target_tips_show_and_tips_click_count)
Кол-во пользователей, совершивших tips_show 2504
Кол-во пользователей, совершивших tips_show с целевым действием 425

Кол-во пользователей, совершивших tips_show и tips_click 297
Кол-во пользователей, совершивших tips_show и tips_click с целевым действием 91
In [82]:
successes = np.array([target_tips_show_and_tips_click_count, target_tips_show_count])
trials = np.array([all_tips_show_and_tips_click_count, all_tips_show_count])

alpha = .05 # критический уровень статистической значимости

# пропорция успехов в первой группе:
p1 = successes[0]/trials[0]

# пропорция успехов во второй группе:
p2 = successes[1]/trials[1]

# пропорция успехов в комбинированном датасете:
p_combined = (successes[0] + successes[1]) / (trials[0] + trials[1])

# разница пропорций в датасетах
difference = p1 - p2 

# считаем статистику в ст.отклонениях стандартного нормального распределения
z_value = difference / mth.sqrt(p_combined * (1 - p_combined) * (1/trials[0] + 1/trials[1]))

# задаем стандартное нормальное распределение (среднее 0, ст.отклонение 1)
distr = st.norm(0, 1)  

p_value = (1 - distr.cdf(abs(z_value))) * 2

print('p-значение: ', p_value)

if p_value < alpha:
    print('Отвергаем нулевую гипотезу: между долями есть значимая разница')
else:
    print(
        'Не получилось отвергнуть нулевую гипотезу, нет оснований считать доли разными'
    ) 
p-значение:  9.218316554537864e-09
Отвергаем нулевую гипотезу: между долями есть значимая разница

Вывод:

Существует статистически значимая разница, позволяющая говорить, что конверсия в просмотры ĸонтаĸтов различается у этих двух групп.

Проверим гипотезу: конверсия в просмотры контактов у пользователей кто совершил только tips_show и кто совершил tips_show и favorites_add различается¶

Сформируем нулевую и альтернативную гипотезы.

H0: различий в ĸонверсии в просмотры ĸонтаĸтов у групп нет;

H1: ĸонверсия в просмотры ĸонтаĸтов различается у групп пользователей.

По аналогии с предыдущей проверкой, подготовим данные. Подготовим данные с ĸонверсией пользователей, совершивших tips_show и favorites_add в просмотры ĸонтаĸтов и тольĸо tips_show в просмотры ĸонтаĸтов.

In [83]:
all_tips_show_and_favorites_add = df.query('user_id in @all_tips_show_list and event_name == "favorites_add"')
all_tips_show_and_favorites_add_count = all_tips_show_and_favorites_add['user_id'].nunique()
In [84]:
target_tips_show_and_favorites_add = df.query('user_id in @target_tips_show_list and event_name == "favorites_add"')
target_tips_show_and_favorites_add_count = target_tips_show_and_favorites_add['user_id'].nunique()
In [85]:
all_tips_show_count = all_tips_show_count - all_tips_show_and_favorites_add_count
target_tips_show_count = target_tips_show_count - target_tips_show_and_favorites_add_count
In [86]:
print('Кол-во пользователей, совершивших tips_show', all_tips_show_count)
print('Кол-во пользователей, совершивших tips_show с целевым действием', target_tips_show_count)
print()
print('Кол-во пользователей, совершивших tips_show и favorites_add', all_tips_show_and_favorites_add_count)
print('Кол-во пользователей, совершивших tips_show и favorites_add с целевым действием', target_tips_show_and_favorites_add_count)
Кол-во пользователей, совершивших tips_show 2396
Кол-во пользователей, совершивших tips_show с целевым действием 379

Кол-во пользователей, совершивших tips_show и favorites_add 108
Кол-во пользователей, совершивших tips_show и favorites_add с целевым действием 46
In [87]:
successes = np.array([target_tips_show_and_favorites_add_count, target_tips_show_count])
trials = np.array([all_tips_show_and_favorites_add_count, all_tips_show_count])

alpha = .05 # критический уровень статистической значимости

# пропорция успехов в первой группе:
p1 = successes[0]/trials[0]

# пропорция успехов во второй группе:
p2 = successes[1]/trials[1]

# пропорция успехов в комбинированном датасете:
p_combined = (successes[0] + successes[1]) / (trials[0] + trials[1])

# разница пропорций в датасетах
difference = p1 - p2 

# считаем статистику в ст.отклонениях стандартного нормального распределения
z_value = difference / mth.sqrt(p_combined * (1 - p_combined) * (1/trials[0] + 1/trials[1]))

# задаем стандартное нормальное распределение (среднее 0, ст.отклонение 1)
distr = st.norm(0, 1)  

p_value = (1 - distr.cdf(abs(z_value))) * 2

print('p-значение: ', p_value)

if p_value < alpha:
    print('Отвергаем нулевую гипотезу: между долями есть значимая разница')
else:
    print(
        'Не получилось отвергнуть нулевую гипотезу, нет оснований считать доли разными'
    ) 
p-значение:  4.150013666048835e-13
Отвергаем нулевую гипотезу: между долями есть значимая разница

Вывод:

Существует статистически значимая разница, позволяющая говорить, что конверсия в просмотры ĸонтаĸтов у пользователей, добавивших объявление в избранной и не сделавших это различается.

Проверим гипотезу: конверсия в просмотры контактов у пользователей кто добавил объявление в избранное выше, чем у тех кто не добавил.¶

Сформируем нулевую и альтернативную гипотезы.

H0: различий в ĸонверсии в просмотры ĸонтаĸтов у групп нет;

H1: ĸонверсия в просмотры ĸонтаĸтов различается у групп пользователей.

In [88]:
all_us_count = df['user_id'].nunique()

all_favorites_add = df.query('event_name == "favorites_add"')
all_favorites_add_count = all_favorites_add['user_id'].nunique()
In [89]:
target_us = df.query('user_id in @contacts_yes')['user_id'].nunique()

target_favorites_add = df.query('user_id in @contacts_yes and event_name == "favorites_add"')
target_favorites_add_count = target_favorites_add['user_id'].nunique()
In [90]:
all_us_count = all_us_count - all_favorites_add_count
target_us = target_us - target_favorites_add_count
In [91]:
print('Кол-во пользователей, общее:', all_us_count)
print('Кол-во пользователей, совершивших целевое действием', target_us)
print()
print('Кол-во пользователей, совершивших favorites_add', all_favorites_add_count)
print('Кол-во пользователей, совершивших favorites_add с целевым действием', target_favorites_add_count)
Кол-во пользователей, общее: 3942
Кол-во пользователей, совершивших целевое действием 845

Кол-во пользователей, совершивших favorites_add 351
Кол-во пользователей, совершивших favorites_add с целевым действием 136
In [92]:
successes = np.array([target_favorites_add_count, target_us])
trials = np.array([all_favorites_add_count, all_us_count])

alpha = .05 # критический уровень статистической значимости

# пропорция успехов в первой группе:
p1 = successes[0]/trials[0]

# пропорция успехов во второй группе:
p2 = successes[1]/trials[1]

# пропорция успехов в комбинированном датасете:
p_combined = (successes[0] + successes[1]) / (trials[0] + trials[1])

# разница пропорций в датасетах
difference = p1 - p2 

# считаем статистику в ст.отклонениях стандартного нормального распределения
z_value = difference / mth.sqrt(p_combined * (1 - p_combined) * (1/trials[0] + 1/trials[1]))

# задаем стандартное нормальное распределение (среднее 0, ст.отклонение 1)
distr = st.norm(0, 1)  

p_value = (1 - distr.cdf(abs(z_value))) * 2

print('p-значение: ', p_value)

if p_value < alpha:
    print('Отвергаем нулевую гипотезу: между долями есть значимая разница')
else:
    print(
        'Не получилось отвергнуть нулевую гипотезу, нет оснований считать доли разными'
    ) 
p-значение:  1.3455903058456897e-13
Отвергаем нулевую гипотезу: между долями есть значимая разница

Вывод:

Существует статистически значимая разница, позволяющая говорить, что конверсия в просмотры ĸонтаĸтов различается у этих двух групп.

Общие выводы и рекомендации¶

В начале проекта были поставлены цели:

  1. Исследовать данные о поведении пользователей, с целью последующего поиска механизмов повышения вовлеченности клиентов приложения.
  2. Получить на основе поведения пользователей гипотезы о том как можно было бы улучшить приложение с точки зрения пользовательского опыта.

Проект состоял из ряда шагов и этапов. Основные выводы по каждому этапу:

1. На этапе предобработки было сделано следующее:

  • Полученные датасеты объединены;
  • Проработаны типы данных в столбцах, названия приведены к рабочему виду, убраны повторы в действиях пользователей, проверен период данных.
  • Размер датасета после предобработки - 73079 строк, 4 столбца.

2. Проведен исследовательский анализ данных. Основные тезисы:

  • Всего уникальных пользователей в датасете: 4293;
  • 23% пользователей в датасете совершили целевое действие (981 человек), 77% - нет (3312 человек);
  • В датасете содержатся 3 истоника трафика - Яндекс, Google, Другие. Больше всего пользователей приходят из Яндекса (45%).
  • Самое распространенное действие - просмотр рекомендованных объявлений.
  • При том, что больше пользователей приходит из Яндекса, показатели конверсии в целевое действие у пользователей из Яндекса и Google находятся примерно на одном уровне.
  • Наибольшую конверсию из рекомендованных объявлений в целевое действие демонстрируют пользователи, пришедшие из Яндекса (20,46%).
  • В среднем, на пользователя приходится 3 сессии, а средняя длина сессии находится в районе 6 минут. При этом, пользователи совершают не более 24 действий за сессию. Среднее значение лежит около 4 действий за сессию.
  • Метрика DAU показывает, что в среднем количество активных пользователей в день находится в районе 240 - 300 человек.

3. Изучены основные вопросы исследования, сделан ряд наблюдений:

  • Определены сценарии поведения пользователей, построена диаграмма Сэнкей. Выделены 4 основных сценария, среди которых:
  • Сценарий 1. search_1 -- photos_show -- contacts_show. СR сценария 1 = 24%
  • Сценарий 2. tips_show -- contacts_show. СR сценария 2 = 18%
  • Сценарий 3. map -- tips_show -- contacts_show. СR сценария 3 = 19%
  • Cценарий 4. map -- advert_open -- contacts_show. СR сценария 4 = 6%
  • Для обоих групп пользователей заметно преобладание действия tips_show среди общего количества действий.

Заметна разница в действиях advert_open и map. Среди нецелевых пользователей доля этих действий среди всех выше, чем у целевых пользователей.

4. Проверены статистические гипотезы:

Различается ли конверсия в просмотры контактов у пользователей, которые совершают действия tips_show и tips_click и у пользователей, совершивших только tips_show?

  • Существует статистически значимая разница, позволяющая говорить, что конверсия в просмотры ĸонтаĸтов различается у этих двух групп.

Конверсия в просмотры контактов у пользователей кто совершил только tips_show и кто совершил tips_show и favorites_add различается

  • Существует статистически значимая разница, позволяющая говорить, что конверсия в просмотры ĸонтаĸтов у пользователей, добавивших объявление в избранной и не сделавших это различается.

Конверсия в просмотры контактов у пользователей кто добавил объявление в избранное выше, чем у тех кто не добавил.

  • Существует статистически значимая разница, позволяющая говорить, что конверсия в просмотры ĸонтаĸтов различается у этих двух групп.

5. Рекомендации для заказчика:

  • Основное для пользователя событие - просмотр рекомендованных объявлений, причем как на старте пользовательского пути, так и в дальнейшем. Следует детальнее разобраться в алгоритмах выдачи рекомендаций, при эффективной настройке это поспособствует к увеличению конверсии. Также, следует обратить внимание на действие ticks_clik - кликов по рекомендованным объявлениям заметно мало, следует выяснить почему.
  • Необходимо просмотреть действие favorites_add. Конверсия у пользователей, добавляющих и не добавляющих объявление в избранное различается, а значит, поработав с механизмом добавление в избранное, можно простимулировать конверсию (например, присылать напоминания, что товар лежит в избранном)
  • Просмотреть выдачу рекламных объявлений. Доля открытия карточки не слишком высока.